/*
 * Copyright (c) 2014-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */

package com.facebook.stetho.inspector.elements;

import android.os.SystemClock;
import com.facebook.stetho.common.Accumulator;
import com.facebook.stetho.common.ArrayListAccumulator;
import com.facebook.stetho.common.LogUtil;
import com.facebook.stetho.common.Util;
import com.facebook.stetho.inspector.helper.ObjectIdMapper;
import com.facebook.stetho.inspector.helper.ThreadBoundProxy;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Queue;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
import javax.annotation.concurrent.GuardedBy;
import org.hapjs.inspector.DOMAccumulator;
import org.hapjs.render.css.CSSStyleDeclaration;

public final class Document extends ThreadBoundProxy {
    private final DocumentProviderFactory mFactory;
    private final ObjectIdMapper mObjectIdMapper;
    private final Queue<Object> mCachedUpdateQueue;

    private DocumentProvider mDocumentProvider;
    private ShadowDocument mShadowDocument;
    private UpdateListenerCollection mUpdateListeners;
    private ChildEventingList mCachedChildEventingList;
    private ArrayListAccumulator<Object> mCachedChildrenAccumulator;
    private AttributeListAccumulator mCachedAttributeAccumulator;

    @GuardedBy("this")
    private int mReferenceCounter;

    public Document(DocumentProviderFactory factory) {
        super(factory);

        mFactory = factory;
        mObjectIdMapper = new DocumentObjectIdMapper();
        mReferenceCounter = 0;
        mUpdateListeners = new UpdateListenerCollection();
        mCachedUpdateQueue = new ArrayDeque<>();
    }

    private static void updateListenerChildren(
            ChildEventingList listenerChildren,
            List<Object> newChildren,
            Accumulator<Object> insertedElements) {
        int index = 0;
        while (index <= listenerChildren.size()) {
            // Insert new items that were added to the end of the list
            if (index == listenerChildren.size()) {
                if (index == newChildren.size()) {
                    break;
                }

                final Object newElement = newChildren.get(index);
                listenerChildren.addWithEvent(index, newElement, insertedElements);
                ++index;
                continue;
            }

            // Remove old items that were removed from the end of the list
            if (index == newChildren.size()) {
                listenerChildren.removeWithEvent(index);
                continue;
            }

            final Object listenerElement = listenerChildren.get(index);
            final Object newElement = newChildren.get(index);

            // This slot has exactly what we need to have here.
            if (listenerElement == newElement) {
                ++index;
                continue;
            }

            int newElementListenerIndex = listenerChildren.indexOf(newElement);
            if (newElementListenerIndex == -1) {
                listenerChildren.addWithEvent(index, newElement, insertedElements);
                ++index;
                continue;
            }

            // TODO: use longest common substring to decide whether to
            //       1) remove(newElementListenerIndex)-then-add(index), or
            //       2) remove(index) and let a subsequent loop iteration do add() (that is, when index
            //          catches up the current value of newElementListenerIndex)
            //       Neither one of these is the best strategy -- it depends on context.

            listenerChildren.removeWithEvent(newElementListenerIndex);
            listenerChildren.addWithEvent(index, newElement, insertedElements);

            ++index;
        }
    }

    public synchronized void addRef() {
        if (mReferenceCounter++ == 0) {
            init();
        }
    }

    public synchronized void release() {
        if (mReferenceCounter > 0) {
            if (--mReferenceCounter == 0) {
                cleanUp();
            }
        }
    }

    private void init() {
        mDocumentProvider = mFactory.create();

        mDocumentProvider.postAndWait(
                new Runnable() {
                    @Override
                    public void run() {
                        mShadowDocument = new ShadowDocument(mDocumentProvider.getRootElement());
                        createShadowDocumentUpdate().commit();
                        mDocumentProvider.setListener(new ProviderListener());
                    }
                });
    }

    private void cleanUp() {
        mDocumentProvider.postAndWait(
                new Runnable() {
                    @Override
                    public void run() {
                        mDocumentProvider.setListener(null);
                        mShadowDocument = null;
                        mObjectIdMapper.clear();
                        mDocumentProvider.dispose();
                        mDocumentProvider = null;
                    }
                });

        mUpdateListeners.clear();
    }

    public void addUpdateListener(UpdateListener updateListener) {
        mUpdateListeners.add(updateListener);
    }

    public void removeUpdateListener(UpdateListener updateListener) {
        mUpdateListeners.remove(updateListener);
    }

    public @Nullable NodeDescriptor getNodeDescriptor(Object element) {
        verifyThreadAccess();
        return mDocumentProvider.getNodeDescriptor(element);
    }

    public void highlightElement(Object element, int color) {
        verifyThreadAccess();
        mDocumentProvider.highlightElement(element, color);
    }

    public void hideHighlight() {
        verifyThreadAccess();
        mDocumentProvider.hideHighlight();
    }

    public void setInspectModeEnabled(boolean enabled) {
        verifyThreadAccess();
        mDocumentProvider.setInspectModeEnabled(enabled);
    }

    public @Nullable Integer getNodeIdForElement(Object element) {
        // We don't actually call verifyThreadAccess() for performance.
        // verifyThreadAccess();
        // INSPECTOR MOD
        // return mObjectIdMapper.getIdForObject(element);
        Integer id = mObjectIdMapper.getIdForObject(element);
        return id == null ? 0 : id;
    }

    public @Nullable Object getElementForNodeId(int id) {
        // We don't actually call verifyThreadAccess() for performance.
        // verifyThreadAccess();
        return mObjectIdMapper.getObjectForId(id);
    }

    // INSPECTOR MOD
    // public void setAttributesAsText(Object element, String text) {
    public void setAttributesAsText(Object element, String name, String text) {
        verifyThreadAccess();
        // INSPECTOR MOD
        // mDocumentProvider.setAttributesAsText(element, text);
        mDocumentProvider.setAttributesAsText(element, name, text);
    }

    public void getElementStyleRuleNames(Object element, StyleRuleNameAccumulator accumulator) {
        NodeDescriptor nodeDescriptor = getNodeDescriptor(element);

        nodeDescriptor.getStyleRuleNames(element, accumulator);
    }

    public void getElementStyles(Object element, String ruleName, StyleAccumulator accumulator) {
        NodeDescriptor nodeDescriptor = getNodeDescriptor(element);

        nodeDescriptor.getStyles(element, ruleName, accumulator);
    }

    // INSPECTOR ADD BEGIN
    public void getElementInlineStyles(Object element, StyleAccumulator accumulator) {
        NodeDescriptor nodeDescriptor = getNodeDescriptor(element);

        nodeDescriptor.getInlineStyle(element, accumulator);
    }
    // INSPECTOR END

    public void setElementStyle(Object element, String ruleName, CSSStyleDeclaration style) {
        NodeDescriptor nodeDescriptor = getNodeDescriptor(element);

        nodeDescriptor.setStyle(element, ruleName, style);
    }

    public void setElementStyle(Object element, String ruleName, String name, String value) {
        NodeDescriptor nodeDescriptor = getNodeDescriptor(element);

        nodeDescriptor.setStyle(element, ruleName, name, value);
    }

    public void getElementComputedStyles(Object element,
                                         ComputedStyleAccumulator styleAccumulator) {
        NodeDescriptor nodeDescriptor = getNodeDescriptor(element);

        nodeDescriptor.getComputedStyles(element, styleAccumulator);
    }

    public DocumentView getDocumentView() {
        verifyThreadAccess();
        return mShadowDocument;
    }

    // INSPECTOR ADD BEGIN
    public void getElementBoxModel(Object element, DOMAccumulator domAccumulator) {
        NodeDescriptor nodeDescriptor = getNodeDescriptor(element);

        nodeDescriptor.getBoxModel(element, domAccumulator);
    }
    // INSPECTOR END

    public void setElementWithHTML(Object element, String outerHTML) {
        NodeDescriptor nodeDescriptor = getNodeDescriptor(element);

        nodeDescriptor.setOuterHTML(element, outerHTML);
    }

    public Object getRootElement() {
        verifyThreadAccess();

        Object rootElement = mDocumentProvider.getRootElement();
        if (rootElement == null) {
            // null for rootElement is not allowed. We could support it, but our current
            // implementation won't ever run into this, so let's punt on it for now.
            throw new IllegalStateException();
        }

        if (rootElement != mShadowDocument.getRootElement()) {
            // We don't support changing the root element. This is handled differently by the
            // protocol than updates to an existing DOM, and we don't have any case in our
            // current implementation that causes this to happen, so let's punt on it for now.
            throw new IllegalStateException();
        }

        return rootElement;
    }

    public void findMatchingElements(String query, Accumulator<Integer> matchedIds) {
        verifyThreadAccess();

        final Pattern queryPattern =
                Pattern.compile(Pattern.quote(query), Pattern.CASE_INSENSITIVE);
        final Object rootElement = mDocumentProvider.getRootElement();

        findMatches(rootElement, queryPattern, matchedIds);
    }

    private void findMatches(Object element, Pattern queryPattern,
                             Accumulator<Integer> matchedIds) {
        final ElementInfo info = mShadowDocument.getElementInfo(element);

        // INSPECTOR ADD BEGIN:
        if (info == null) {
            return;
        }
        // END
        for (int i = 0, size = info.children.size(); i < size; i++) {
            final Object childElement = info.children.get(i);

            if (doesElementMatch(childElement, queryPattern)) {
                matchedIds.store(mObjectIdMapper.getIdForObject(childElement));
            }

            findMatches(childElement, queryPattern, matchedIds);
        }
    }

    private boolean doesElementMatch(Object element, Pattern queryPattern) {
        AttributeListAccumulator accumulator = acquireCachedAttributeAccumulator();
        NodeDescriptor descriptor = mDocumentProvider.getNodeDescriptor(element);

        descriptor.getAttributes(element, accumulator);

        for (int i = 0, n = accumulator.size(); i < n; i++) {
            if (queryPattern.matcher(accumulator.get(i)).find()) {
                releaseCachedAttributeAccumulator(accumulator);
                return true;
            }
        }

        releaseCachedAttributeAccumulator(accumulator);
        return queryPattern.matcher(descriptor.getNodeName(element)).find();
    }

    private ChildEventingList acquireChildEventingList(
            Object parentElement, DocumentView documentView) {
        ChildEventingList childEventingList = mCachedChildEventingList;

        if (childEventingList == null) {
            childEventingList = new ChildEventingList();
        }

        mCachedChildEventingList = null;

        childEventingList.acquire(parentElement, documentView);
        return childEventingList;
    }

    private void releaseChildEventingList(ChildEventingList childEventingList) {
        childEventingList.release();
        if (mCachedChildEventingList == null) {
            mCachedChildEventingList = childEventingList;
        }
    }

    private AttributeListAccumulator acquireCachedAttributeAccumulator() {
        AttributeListAccumulator accumulator = mCachedAttributeAccumulator;

        if (accumulator == null) {
            accumulator = new AttributeListAccumulator();
        }

        mCachedChildrenAccumulator = null;

        return accumulator;
    }

    private void releaseCachedAttributeAccumulator(AttributeListAccumulator accumulator) {
        accumulator.clear();

        if (mCachedAttributeAccumulator == null) {
            mCachedAttributeAccumulator = accumulator;
        }
    }

    private ArrayListAccumulator<Object> acquireChildrenAccumulator() {
        ArrayListAccumulator<Object> accumulator = mCachedChildrenAccumulator;
        if (accumulator == null) {
            accumulator = new ArrayListAccumulator<>();
        }
        mCachedChildrenAccumulator = null;
        return accumulator;
    }

    private void releaseChildrenAccumulator(ArrayListAccumulator<Object> accumulator) {
        accumulator.clear();
        if (mCachedChildrenAccumulator == null) {
            mCachedChildrenAccumulator = accumulator;
        }
    }

    private ShadowDocument.Update createShadowDocumentUpdate() {
        verifyThreadAccess();

        if (mDocumentProvider.getRootElement() != mShadowDocument.getRootElement()) {
            throw new IllegalStateException();
        }

        ArrayListAccumulator<Object> childrenAccumulator = acquireChildrenAccumulator();

        ShadowDocument.UpdateBuilder updateBuilder = mShadowDocument.beginUpdate();
        mCachedUpdateQueue.add(mDocumentProvider.getRootElement());

        while (!mCachedUpdateQueue.isEmpty()) {
            final Object element = mCachedUpdateQueue.remove();
            NodeDescriptor descriptor = mDocumentProvider.getNodeDescriptor(element);
            mObjectIdMapper.putObject(element);
            descriptor.getChildren(element, childrenAccumulator);

            for (int i = 0, size = childrenAccumulator.size(); i < size; ++i) {
                Object child = childrenAccumulator.get(i);
                if (child != null) {
                    mCachedUpdateQueue.add(child);
                } else {
                    // This could be indicative of a bug in Stetho code, but could also be caused by a
                    // custom element of some kind, e.g. ViewGroup. Let's not allow it to kill the hosting
                    // app.
                    LogUtil.e(
                            "%s.getChildren() emitted a null child at position %s for element %s",
                            descriptor.getClass().getName(), Integer.toString(i), element);

                    childrenAccumulator.remove(i);
                    --i;
                    --size;
                }
            }

            updateBuilder.setElementChildren(element, childrenAccumulator);
            childrenAccumulator.clear();
        }

        releaseChildrenAccumulator(childrenAccumulator);

        return updateBuilder.build();
    }

    private void updateTree() {
        long startTimeMs = SystemClock.elapsedRealtime();

        ShadowDocument.Update docUpdate = createShadowDocumentUpdate();
        boolean isEmpty = docUpdate.isEmpty();
        if (isEmpty) {
            docUpdate.abandon();
        } else {
            applyDocumentUpdate(docUpdate);
        }

        long deltaMs = SystemClock.elapsedRealtime() - startTimeMs;
        LogUtil.d(
                "Document.updateTree() completed in %s ms%s",
                Long.toString(deltaMs), isEmpty ? " (no changes)" : "");
    }

    private void applyDocumentUpdate(final ShadowDocument.Update docUpdate) {
        // TODO: it'd be nice if we could delegate our calls into mPeerManager.sendNotificationToPeers()
        //       to a background thread so as to offload the UI from JSON serialization stuff

        // Applying the ShadowDocument.Update is done in five stages:

        // Stage 1: any elements that have been disconnected from the tree, and any elements in those
        // sub-trees which have not been reconnected to the tree, should be garbage collected. For now
        // we gather a list of garbage element IDs which we use in stages 2 to test a changed element
        // to see if it's also garbage. Then during stage 3 we use this list to unhook all of the
        // garbage elements.

        // This is used to collect the garbage element IDs in stage 1. It is sorted before stage 2 so
        // that it can use a binary search as a quick "contains()" method.
        // Note that this could be accomplished in a simpler way by employing a HashSet<Object> and
        // storing the element Objects. However, HashSet wraps HashMap and we would have a lot more
        // allocations (Map.Entry, iterator during stage 3) and thus GC pressure.
        // Using SparseArray wouldn't be good because it ensures sorted ordering as you go, but we don't
        // need that during stage 1. Using ArrayList with int boxing is fine because the Integers are
        // already boxed inside of mObjectIdMapper and we make sure to reuse that allocation.
        final ArrayList<Integer> garbageElementIds = new ArrayList<>();

        docUpdate.getGarbageElements(
                new Accumulator<Object>() {
                    @Override
                    public void store(Object element) {
                        Integer nodeId = Util.throwIfNull(mObjectIdMapper.getIdForObject(element));
                        ElementInfo newElementInfo = docUpdate.getElementInfo(element);

                        // Only raise onChildNodeRemoved for the root of a disconnected tree. The remainder of
                        // the
                        // sub-tree is included automatically, so we don't need to send events for those.
                        // INSPECTOR MOD BEGIN:
                        // if (newElementInfo.parentElement == null) {
                        //  ElementInfo oldElementInfo = mShadowDocument.getElementInfo(element);
                        //  int parentNodeId = mObjectIdMapper.getIdForObject(oldElementInfo.parentElement);
                        //  mUpdateListeners.onChildNodeRemoved(parentNodeId, nodeId);
                        // }
                        if (newElementInfo != null && newElementInfo.parentElement == null) {
                            ElementInfo oldElementInfo = mShadowDocument.getElementInfo(element);
                            if (mObjectIdMapper != null && oldElementInfo != null) {
                                int parentNodeId = mObjectIdMapper
                                        .getIdForObject(oldElementInfo.parentElement);
                                mUpdateListeners.onChildNodeRemoved(parentNodeId, nodeId);
                            }
                        }
                        // END
                        garbageElementIds.add(nodeId);
                    }
                });

        Collections.sort(garbageElementIds);

        // Stage 2: remove all elements that have been reparented. Otherwise we get into trouble if we
        // transmit an event to insert under the new parent before we've transmitted an event to remove
        // it from the old parent. The removal event is ignored because the parent doesn't match the
        // listener's expectations, so we get ghost elements that are stuck and can't be exorcised.
        docUpdate.getChangedElements(
                new Accumulator<Object>() {
                    @Override
                    public void store(Object element) {
                        Integer nodeId = Util.throwIfNull(mObjectIdMapper.getIdForObject(element));

                        // Skip garbage elements
                        if (Collections.binarySearch(garbageElementIds, nodeId) >= 0) {
                            return;
                        }

                        // Skip new elements
                        final ElementInfo oldElementInfo = mShadowDocument.getElementInfo(element);
                        if (oldElementInfo == null) {
                            return;
                        }

                        final ElementInfo newElementInfo = docUpdate.getElementInfo(element);
                        // INSPECTOR MOD:
                        // if (newElementInfo.parentElement != oldElementInfo.parentElement) {
                        if (newElementInfo != null
                                && newElementInfo.parentElement != oldElementInfo.parentElement
                                && mObjectIdMapper != null) {
                            int parentNodeId =
                                    mObjectIdMapper.getIdForObject(oldElementInfo.parentElement);
                            mUpdateListeners.onChildNodeRemoved(parentNodeId, nodeId);
                        }
                    }
                });

        // Stage 3: unhook garbage elements
        for (int i = 0, n = garbageElementIds.size(); i < n; ++i) {
            mObjectIdMapper.removeObjectById(garbageElementIds.get(i));
        }

        // Stage 4: transmit all other changes to our listener. This includes inserting reparented
        // elements that we removed in the 2nd stage.
        docUpdate.getChangedElements(
                new Accumulator<Object>() {
                    private final HashSet<Object> listenerInsertedElements = new HashSet<>();

                    private Accumulator<Object> insertedElements =
                            new Accumulator<Object>() {
                                @Override
                                public void store(Object element) {
                                    if (docUpdate.isElementChanged(element)) {
                                        // We only need to track changed elements because unchanged elements will never
                                        // be
                                        // encountered by the code below, in store(), which uses this Set to skip
                                        // elements that
                                        // don't need to be processed.
                                        listenerInsertedElements.add(element);
                                    }
                                }
                            };

                    @Override
                    public void store(Object element) {
                        if (!mObjectIdMapper.containsObject(element)) {
                            // The element was garbage and has already been removed. At this stage that's okay and
                            // we
                            // just skip it and continue forward with the algorithm.
                            return;
                        }

                        if (listenerInsertedElements.contains(element)) {
                            // This element was already transmitted in its entirety by an onChildNodeInserted
                            // event.
                            // Trying to send any further updates about it is both unnecessary and incorrect (we'd
                            // end up with duplicated elements and really bad performance).
                            return;
                        }

                        final ElementInfo oldElementInfo = mShadowDocument.getElementInfo(element);
                        final ElementInfo newElementInfo = docUpdate.getElementInfo(element);

                        final List<Object> oldChildren =
                                (oldElementInfo != null) ? oldElementInfo.children :
                                        Collections.emptyList();

                        // INSPECTOR DEL
                        // final List<Object> newChildren = newElementInfo.children;

                        // This list is representative of our listener's view of the Document (ultimately, this
                        // means Chrome DevTools). We need to sync it up with newChildren.
                        ChildEventingList listenerChildren =
                                acquireChildEventingList(element, docUpdate);
                        for (int i = 0, n = oldChildren.size(); i < n; ++i) {
                            final Object childElement = oldChildren.get(i);
                            if (mObjectIdMapper.containsObject(childElement)) {
                                final ElementInfo newChildElementInfo =
                                        docUpdate.getElementInfo(childElement);
                                if (newChildElementInfo != null
                                        && newChildElementInfo.parentElement != element) {
                                    // This element was reparented, so we already told our listener to remove it.
                                } else {
                                    listenerChildren.add(childElement);
                                }
                            }
                        }
                        // INSPECTOR MOD BEGIN:
                        // updateListenerChildren(listenerChildren, newChildren, insertedElements);
                        if (newElementInfo != null) {
                            final List<Object> newChildren = newElementInfo.children;
                            updateListenerChildren(listenerChildren, newChildren, insertedElements);
                        }
                        // END
                        releaseChildEventingList(listenerChildren);
                    }
                });

        // Stage 5: Finally, commit the update to the ShadowDocument.
        docUpdate.commit();
    }

    public interface UpdateListener {
        void onAttributeModified(Object element, String name, String value);

        void onAttributeRemoved(Object element, String name);

        void onInspectRequested(Object element);

        void onChildNodeRemoved(int parentNodeId, int nodeId);

        void onChildNodeInserted(
                DocumentView view,
                Object element,
                int parentNodeId,
                int previousNodeId,
                Accumulator<Object> insertedItems);
    }

    public static final class AttributeListAccumulator extends ArrayList<String>
            implements AttributeAccumulator {

        @Override
        public void store(String name, String value) {
            add(name);
            add(value);
        }
    }

    /**
     * A private implementation of {@link List} that transmits our changes to our listener (and,
     * ultimately, to the DevTools client).
     */
    private final class ChildEventingList extends ArrayList<Object> {
        private Object mParentElement = null;
        private int mParentNodeId = -1;
        private DocumentView mDocumentView;

        public void acquire(Object parentElement, DocumentView documentView) {
            mParentElement = parentElement;

            mParentNodeId =
                    (mParentElement == null) ? -1 : mObjectIdMapper.getIdForObject(mParentElement);

            mDocumentView = documentView;
        }

        public void release() {
            clear();

            mParentElement = null;
            mParentNodeId = -1;
            mDocumentView = null;
        }

        public void addWithEvent(int index, Object element, Accumulator<Object> insertedElements) {
            Object previousElement = (index == 0) ? null : get(index - 1);

            int previousNodeId =
                    (previousElement == null) ? -1 :
                            mObjectIdMapper.getIdForObject(previousElement);

            add(index, element);

            mUpdateListeners.onChildNodeInserted(
                    mDocumentView, element, mParentNodeId, previousNodeId, insertedElements);
        }

        public void removeWithEvent(int index) {
            Object element = remove(index);
            // INSPECTOR ADD BEGIN:
            if (mObjectIdMapper == null) {
                return;
            }
            // END
            int nodeId = mObjectIdMapper.getIdForObject(element);
            mUpdateListeners.onChildNodeRemoved(mParentNodeId, nodeId);
        }
    }

    private class UpdateListenerCollection implements UpdateListener {
        private final List<UpdateListener> mListeners;
        private volatile UpdateListener[] mListenersSnapshot;

        public UpdateListenerCollection() {
            mListeners = new ArrayList<>();
        }

        public synchronized void add(UpdateListener listener) {
            mListeners.add(listener);
            mListenersSnapshot = null;
        }

        public synchronized void remove(UpdateListener listener) {
            mListeners.remove(listener);
            mListenersSnapshot = null;
        }

        public synchronized void clear() {
            mListeners.clear();
            mListenersSnapshot = null;
        }

        private UpdateListener[] getListenersSnapshot() {
            while (true) {
                final UpdateListener[] listenersSnapshot = mListenersSnapshot;
                if (listenersSnapshot != null) {
                    return listenersSnapshot;
                }
                synchronized (this) {
                    if (mListenersSnapshot == null) {
                        mListenersSnapshot =
                                mListeners.toArray(new UpdateListener[mListeners.size()]);
                        return mListenersSnapshot;
                    }
                }
            }
        }

        @Override
        public void onAttributeModified(Object element, String name, String value) {
            for (UpdateListener listener : getListenersSnapshot()) {
                listener.onAttributeModified(element, name, value);
            }
        }

        @Override
        public void onAttributeRemoved(Object element, String name) {
            for (UpdateListener listener : getListenersSnapshot()) {
                listener.onAttributeRemoved(element, name);
            }
        }

        @Override
        public void onInspectRequested(Object element) {
            for (UpdateListener listener : getListenersSnapshot()) {
                listener.onInspectRequested(element);
            }
        }

        @Override
        public void onChildNodeRemoved(int parentNodeId, int nodeId) {
            for (UpdateListener listener : getListenersSnapshot()) {
                listener.onChildNodeRemoved(parentNodeId, nodeId);
            }
        }

        @Override
        public void onChildNodeInserted(
                DocumentView view,
                Object element,
                int parentNodeId,
                int previousNodeId,
                Accumulator<Object> insertedItems) {
            for (UpdateListener listener : getListenersSnapshot()) {
                listener.onChildNodeInserted(view, element, parentNodeId, previousNodeId,
                        insertedItems);
            }
        }
    }

    private final class DocumentObjectIdMapper extends ObjectIdMapper {
        @Override
        protected void onMapped(Object object, int id) {
            verifyThreadAccess();

            NodeDescriptor descriptor = mDocumentProvider.getNodeDescriptor(object);
            descriptor.hook(object);
        }

        @Override
        protected void onUnmapped(Object object, int id) {
            verifyThreadAccess();

            NodeDescriptor descriptor = mDocumentProvider.getNodeDescriptor(object);
            descriptor.unhook(object);
        }
    }

    private final class ProviderListener implements DocumentProviderListener {
        @Override
        public void onPossiblyChanged() {
            updateTree();
        }

        @Override
        public void onAttributeModified(Object element, String name, String value) {
            verifyThreadAccess();
            mUpdateListeners.onAttributeModified(element, name, value);
        }

        @Override
        public void onAttributeRemoved(Object element, String name) {
            verifyThreadAccess();
            mUpdateListeners.onAttributeRemoved(element, name);
        }

        @Override
        public void onInspectRequested(Object element) {
            verifyThreadAccess();
            mUpdateListeners.onInspectRequested(element);
        }
    }
}
